feat(runtime): bind an artifact's install-time granted permissions to its plugins at load - #17137
feat(runtime): bind an artifact's install-time granted permissions to its plugins at load#17137claude[bot] wants to merge 7 commits into
Conversation
…er plugin at load
Wire `EnvironmentArtifactSchema.grantedPermissions` into
`PluginPermissionEnforcer.registerGrantedPermissions` at materialize time —
the consumer half the artifact contract names, and the key-to-plugin binding
that did not exist before: one `AppPlugin` covers a whole artifact, so nothing
in the load path could say which package a grant entry belonged to.
Absent, `{}` and a consented entry stay three distinct states, both directions.
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Co-authored-by: Claude <noreply@anthropic.com>
…ugin-permission-load-gate
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
… unwrap
The `{ schemaVersion, metadata }` unwrap hands the kernel `metadata` alone and
drops every key beside it, so the install-time consented set — which the
artifact contract puts BESIDE `metadata` — never reached the loader that the
contract names as its consumer. Silent, and indistinguishable from the
legitimate "no consent record" reading.
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bf8111b87a5a706f51a0f6fa134d9169e5bad1b8 && git checkout bf8111b87a5a706f51a0f6fa134d9169e5bad1b8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 702614108578b56e948bb8cd2a3efa646f4876c2 24e357903dcd840af524be2dbef8fcbee7244839 && git checkout -B drift-repro 702614108578b56e948bb8cd2a3efa646f4876c2 && git merge --no-ff 24e357903dcd840af524be2dbef8fcbee7244839
node scripts/docs-audit/affected-docs.mjs --json 702614108578b56e948bb8cd2a3efa646f4876c2
|
Contract review at
|
…ugin-permission-load-gate
Contract review (clause ②) — PASS WITH FINDINGS, one blocking · head
|
…orbidden `?? {}`
Contract-review rework on #13457. Behaviour is unchanged in every leg; what
moves is naming, one redundant default, and two test assertions that could not
tell the doors apart.
- `ArtifactGrantBinding.gated` / `ungated` -> `registered` / `unregistered`,
and the `[AppPlugin]` log line with them. The old names claimed a gate that
does not exist: this round REGISTERS a consent set and nothing on this tree
queries the registry, which the type's own docblock already said
("registered on the enforcer"). The loader comment "the gate had nothing to
enforce" presupposed the same absent gate.
- Delete `.grantedPermissions ?? {}` in `registerArtifactGrantedPermissions`.
It was a semantic no-op, but it is the exact spelling `app-plugin.ts` forbids
by name on this key. Replaced by the narrowing that states the fact the walk
already established: `registered` is non-empty only on the plain-record
branch, so the loop reads the carrier's own record or iterates nothing.
- Correct the "closed by two doors" claim. `{ id: '', name: 'x' }` passes BOTH
doors, because `artifactPackageId` is `id || name`; the fixture set id and
name to `''` together and hid the fallback. The escaping case is now pinned,
including its fail-OPEN reading through the enforcer's own readback: the
unattributable `''` key binds to nothing, is reported `unbound`, and denies
nothing. No refusal is added -- that fork is #17148.
- Tighten both door tests. Each asserted `/no usable package id|not a package
entry/`, so either passed on either door and neither pinned which fired.
Both doors raise the same ADR-0112 code and status, so each test now pins the
shared envelope plus the message unique to its own door, and asserts the
other door's message is absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…ugin-permission-load-gate
Standing down at the gate — contract review cannot run at tier right now · head
|
Part of #13457
Clause-②: yes— this round moves a published behaviour: an environment artifact's install-time consented set now reachesPluginPermissionEnforcer, and an artifact whose envelope carried consent records now delivers them where before it delivered none. Not downgradeable here;needs:contract-reviewcomes off on a contract-tier PASS, not by this PR.Draft, deliberately. One design fork below is a decision, not a patch, and is reported rather than chosen. See "What is NOT delivered".
The landing site, named before anything else
packages/runtime/src/security/artifact-granted-permissions.ts(new)AppPlugin.init()—packages/runtime/src/app-plugin.tspackages/runtime/src/load-artifact-bundle.tspackages/runtime/src/{index,security/index}.tspackages/core/src/security/plugin-permission-enforcer.tswas in the declared surface and is untouched — every symbol the wiring needs (createPluginPermissionEnforcer,registerGrantedPermissions,buildPermissionsFromGrants) was already exported.packages/core/src/security/index.tsis likewise untouched, so the round never depended on the #17101 fence lifting.packages/spec/**is untouched.AppPlugin.init()is the site because it is the single point where an environment artifact becomes a kernel plugin on both paths — the self-hostedcreateStandaloneStackand the control plane'sArtifactKernelFactory, which constructs the same object — so the consent records reach the enforcer without either caller changing a line.The #7500 re-measurement — the reading STILL HOLDS
Measured by
git grepon this branch's own head, with a control that fires (the order's warning was correct:permissionEnforcerreturned 0 for the dispatching seat; on a real checkout it returns 4, all inside the enforcer module — a lower-case symbol that does exist).PluginPermissionEnforcercreatePluginPermissionEnforcerpermissionEnforcer(control, fires)plugin-permission-enforcer.ts—SecurePluginContext's own private fieldSecurePluginContextenforce{ServiceAccess,HookTrigger,NetworkRequest,FileRead,FileWrite}⇒ Zero production callers before this PR, exactly as #7500 read it. The re-export and the two
packages/specfiles the dispatch order flagged are prose and a docblock, not calls. This PR creates the first production caller.Constraint 2 — the key-to-plugin binding, which is the substance
The map is keyed by the plugin manifest
id. OneAppPlugincovers a whole artifact and registers itself under a single kernel plugin name, so nothing in the load path could say which of an artifact's packages a grant entry belonged to. The seam resolves an artifact's carried package ids through the platform's one package sorter (resolveArtifactPackageOrder, ADR-0130 D4/D5) and unwraps each body the same wayAppPlugin's own constructor does, so the ids it registers under are the ids the platform names those packages by — on the flattened shape and onpackages[]alike.Pinned: the map key is
com.acme.crm, never the kernel plugin nameplugin.app.com.acme.crm. Those are different strings and the test asserts both.Absent is not
{}, in both directionsThe walk is driven by the map's own keys, never by the package list. That is the whole guard, and it is one keystroke from its opposite:
registerGrantedPermissions(id, undefined)registers a deny-everything bag, so a package-list-driven loop would come up denying every first-party plugin in the artifact — the boot brick.grantedPermissionskeypermissionEnforcer === undefined;getPluginPermissions(id) === undefined{}grantBinding.declared === true,registered: []{}getPluginPermissions(id)is defined and denies every service, hook, host, path{ services: [...] }warngrantBinding.unboundThe absent case and the
{}-entry case both deny; only one of them is a decision the installer made, andgetPluginPermissionstells them apart. The envelope carry uses!== undefinedfor the same reason.Constraint 1 — which unattributable-consent spellings the doors refuse, and the ONE they do not
The contract has no spelling for "a consent record exists but cannot be attributed": when a manifest carries no top-level string
idthe producer emits it under no name, so to a consumer it is indistinguishable from "no consent record". What the doors actually do, re-measured on this head against the built@objectstack/core:Door 1 is the schema:
ManifestSchema.idis a requiredz.string()— with no.min(1)—AssembledPackageBodySchemaextends it, andObjectStackDefinitionSchema.packagesis an array of that, so a package with no top-levelidis refused at the artifact door. Door 2 isartifactPackageId, which maps the empty string door 1 admits toundefined, andresolveArtifactPackageOrderthen refuses the entry (INVALID_ARTIFACT_PACKAGE_ENTRY).⭐ The case that escapes BOTH.
artifactPackageIdisid || name, notid ?? name. So{ id: '', name: 'x' }clears door 1 (a string is a string) and clears door 2 (the fallback yields'x'), and the package is carried asx. The old fixture setidandnameto''together, which is exactly why the fallback never showed itself. A consent record keyed by the unattributable''therefore DOES reach this seam, where it binds to no package the artifact carries.That residual is fail-OPEN, and stays fail-open in this round. The
''key is reported ongrantBinding.unboundand atwarn, and is registered nowhere; the package loads with no consent record at all, exactly as an artifact that never declared one does. Nothing is silently denied. Whether an unbindable consent record should instead REFUSE the artifact is an open decision carried by #17148 — the correction here is to the measurement and to the pins, ⛔ never to the behaviour.Both doors and the escaping case are pinned separately in
artifact-granted-permissions.test.ts. Each door now asserts the ADR-0112 envelope the two share (INVALID_ARTIFACT_PACKAGE_ENTRY/ 422) plus the message unique to itself, and asserts the other door's message is absent. The previous pair both asserted/no usable package id|not a package entry/, so either test passed on either door: they pinned "refused by some door", never which one fired.packages[]at all takes the sorter's single-package branch, which returns the artifact unvalidated, andartifactPackageIdfalls back toname. Whether the control plane can serve such an envelope with agrantedPermissionskey is a fact aboutArtifactKernelFactory, which is not in this session's read scope: NOT MEASURED. In that residual the seam has no key to act on and behaves as it does for any package the map does not name. The fork this leaves open is in the report and in "What is NOT delivered".What is NOT delivered, and why it is a decision
This PR registers the consented set. It does not intercept access. Every enforcement surface
PluginPermissionEnforcerexposes is reached throughSecurePluginContext— per-plugin context construction, i.e. the ADR-0025 materialize seam, which the 2026-09-01 ruling on this card put out of bounds for either half. So an entry registered here is queried by nothing on this tree yet.Two doors were measured and both are closed to this round:
warn) needs a new error code, and under the [Decision] Clause ② on an UNREGISTERED error code carried by a thrown value: #14552 landedno, #15963 landsyes, and they are the same class #16404 ruling every code that ships indistis registered in the spec'sERROR_CODE_LEDGER— apackages/specedit this round is forbidden to make.⇒ the unbindable case is reported at
warnand recorded ongrantBinding.unbound. Whether it should refuse the artifact instead is the maintainer's call, not this PR's.Of the four permission classes, this round makes none enforceable at access time and all four carried and registered. Also measured while looking for a load-time gate that needed no new vocabulary: the grant's
hooksclass spells hooksrecord.beforeInsert, a string that appears nowhere in this repo outside permission examples — the declarative hook registry spells the same thing{ object, events: ['beforeInsert'] }. Bridging them is an invented mapping, so no hook gate was built.Verification — first round on
8d1e62f3e, rework round on24e357903Exit codes captured by redirect-then-
$?, never across a pipe; gate verdicts quoted from the gate's own line.pnpm --filter '@objectstack/runtime^...' buildVERDICT command-exit 0pnpm --filter @objectstack/runtime run testpnpm --filter @objectstack/runtime run typecheckcheck:test-typecheck: OKnode scripts/pm/dispatch-gates.mjs --commands→ 58 families, all 58 run; reconciled with--ran: "58 derived famil(ies) accounted for — 58 run, 0 NOT-MEASURED"check:nul-bytes+ a hand scan of all 9 changed paths for control bytesTwo families exited 3, which is each gate's own
PREREQUISITE NOT METcode — "⛔ This is NOT a pass and NOT a finding: nothing was measured."check:type-check-debt— named its two missing dists (@objectstack/hono,@objectstack/runtime); those were built and it was re-run: exit 0, "5 ledger entr(ies) re-measured in 368.0s, 55 raw tsc error(s) total, none above its recorded number."check:dual-build-cjs-loads— needs built output for ~38 packages, i.e. a full-farmpnpm build. NOT MEASURED here; CI owns that run. ⛔ Not reported as green.Ablation — 3 legs, each mutated on disk and restored byte-identical
Every leg: inject → prove it reached disk (anchored
grep -cfor the deleted text AND the injected text, plus a blob hash that moved) → run →git checkout HEAD -- <absolute path>→ prove the restore byte-identical (git hash-objectequals the HEAD blob) → finallygit status --porcelainempty. All undertrap ... EXIT INT TERMwith absolute paths. Control leg run first: 14 passed.{}read as absencethis.bindGrantedPermissions(ctx)removed fromAppPlugin.initif (!grants)) moved the blob hash and left every test green — because{}is truthy, so it was a semantic no-op that ablated nothing. The harness treated that green as a failed measurement and refused it; the anchor was changed to a length test (what a?? {}-shaped consumer degrades into) and re-run. Recorded rather than quietly re-rolled.Rework round — readings on
24e357903Behaviour is unchanged in every leg of this round: what moved is naming, one redundant default, one false claim, and two test assertions that could not tell the doors apart. Exit codes captured by redirect-then-
$?, never across a pipe.pnpm --filter '@objectstack/runtime^...' buildVERDICT command-exit 0pnpm --filter @objectstack/runtime run testpnpm --filter @objectstack/runtime run typecheckcheck:test-typecheck: OK, test layer compilednode scripts/pm/dispatch-gates.mjs --commands→ 58 families; reconciled with--ran: "58 derived famil(ies) accounted for — 58 run, 0 NOT-MEASURED"check:dual-build-cjs-loadsexited 3 — its ownPREREQUISITE NOT METcode, naming ~38 unbuilt package dists ("⛔ This is NOT a pass: nothing was measured."). NOT MEASURED here; CI owns that run. ⛔ Not reported as green.check:type-check-debtalso exited 3 on its first run (tscOOM-killed at a 4 GB heap, the gate refusing to record a0it could not stand behind); re-run at 8 GB with the two dists it named built, it is exit 0 — "5 ledger entr(ies) re-measured in 60.5s, 55 raw tsc error(s) total, none above its recorded number."Reverse verification that the rename is real and enforced — one leg, mutated on disk and restored byte-identical.
binding.registered→binding.gatedinapp-plugin.ts(on-disk proof: the anchored count went 1 → 0 and the injected spelling 0 → 1; blob hash moved off the HEAD blob), thentsc --noEmit:The mutation was run only AFTER the implementation was committed, so the restore leg (
git checkout HEAD -- <absolute path>, never a baregit checkout --) points at a HEAD that already carries it. Undertrap ... EXIT INT TERMwith absolute paths.The changeset did not move, and that is a measurement, not an omission. The rename touches
ArtifactGrantBinding's fields; the changeset names the type and the three functions, never a field (grep -wforgated/ungated/registered/unregistered: zero hits). And the type is new in this PR —git cat-file -e origin/main:packages/runtime/src/security/artifact-granted-permissions.tsreports it absent on main, withapp-plugin.tsas a control that fires — sogated/ungatednever shipped and the rename moves no released surface.minorstands.Acceptance notes
packages/core/src/security/admission-tenancy-posture.ts(landed via refactor(core): one shared admission tenancy-posture classification, six seams folded onto it #17101 while this round ran) was read on this head: it classifies thetenancyservice's rejection at admission doors and names neither the permission enforcer nor granted permissions. No interaction. noted, not filed.AppPluginregisters underplugin.app.<manifest id>while the artifact contract keys the grant map on the bare<manifest id>. Both spellings are correct for their own surface, and the seam registers under the contract's. Whoever builds the materialize seam has to query with the bare id, not withAppPlugin.name. noted, not filed — the carrier of this is the ADR-0025 materialize-seam card, which does not exist yet.Generated by Claude Code